/**
 * Copyright (C) 2005-2008 Jive Software. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jivesoftware.openfire.plugin;

import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeMap;

import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.dom4j.QName;
import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.openfire.container.Plugin;
import org.jivesoftware.openfire.container.PluginManager;
import org.jivesoftware.openfire.disco.IQDiscoInfoHandler;
import org.jivesoftware.openfire.disco.IQDiscoItemsHandler;
import org.jivesoftware.openfire.group.Group;
import org.jivesoftware.openfire.group.GroupManager;
import org.jivesoftware.openfire.user.User;
import org.jivesoftware.openfire.user.UserManager;
import org.jivesoftware.util.JiveGlobals;
import org.jivesoftware.util.LocaleUtils;
import org.jivesoftware.util.PropertyEventDispatcher;
import org.jivesoftware.util.PropertyEventListener;
import org.jivesoftware.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmpp.component.Component;
import org.xmpp.component.ComponentException;
import org.xmpp.component.ComponentManager;
import org.xmpp.component.ComponentManagerFactory;
import org.xmpp.forms.DataForm;
import org.xmpp.forms.FormField;
import org.xmpp.packet.IQ;
import org.xmpp.packet.IQ.Type;
import org.xmpp.packet.JID;
import org.xmpp.packet.Packet;
import org.xmpp.packet.PacketError;
import org.xmpp.packet.PacketError.Condition;
import org.xmpp.resultsetmanagement.ResultSet;
import org.xmpp.resultsetmanagement.ResultSetImpl;

/**
 * Provides support for Jabber Search (<a href="http://www.xmpp.org/extensions/xep-0055.html">XEP-0055</a>).
 * <p>
 * 
 * The basic functionality is to query an information repository regarding the possible search fields, to send a search query, and to
 * receive search results. This implementation was primarily designed to use <a href="http://www.xmpp.org/extensions/xep-0004.html">Data
 * Forms</a>, but also supports non-dataform searches.
 * <p/>
 * 
 * @author <a href="mailto:ryan@version2software.com">Ryan Graham</a>
 */
public class SearchPlugin implements Component, Plugin, PropertyEventListener {

	private static final Logger Log = LoggerFactory.getLogger(SearchPlugin.class);

	public static final String NAMESPACE_JABBER_IQ_SEARCH = "jabber:iq:search";
	public static final String SERVICENAME = "plugin.search.serviceName";
	public static final String SERVICEENABLED = "plugin.search.serviceEnabled";
	public static final String EXCLUDEDFIELDS = "plugin.search.excludedFields";
	public static final String GROUPONLY = "plugin.search.groupOnly";

	private UserManager userManager;
	private ComponentManager componentManager;
	private PluginManager pluginManager;

	private String serviceName;
	private boolean serviceEnabled;
	private Collection<String> excludedFields;

	private boolean groupOnly;

	private static String serverName;

	private TreeMap<String, String> fieldLookup = new TreeMap<String, String>(new CaseInsensitiveComparator());
	private Map<String, String> reverseFieldLookup = new HashMap<String, String>();

	/**
	 * A list of field names that are valid in jabber:iq:search
	 */
	public final static Collection<String> validSearchRequestFields = new ArrayList<String>();
	static {
		validSearchRequestFields.add("first");
		validSearchRequestFields.add("last");
		validSearchRequestFields.add("nick");
		validSearchRequestFields.add("email");
		validSearchRequestFields.add("x"); // extended info

		// result set management (XEP-0059)
		validSearchRequestFields.add("set");
	}

	public SearchPlugin() {
		serviceName = JiveGlobals.getProperty(SERVICENAME, "search");
		serviceEnabled = JiveGlobals.getBooleanProperty(SERVICEENABLED, true);
		excludedFields = StringUtils.stringToCollection(JiveGlobals.getProperty(EXCLUDEDFIELDS, ""));
		groupOnly = JiveGlobals.getBooleanProperty(GROUPONLY);

		serverName = XMPPServer.getInstance().getServerInfo().getXMPPDomain();
		userManager = UserManager.getInstance();

		// Some clients, such as Miranda, are hard-coded to search specific fields,
		// so we map those fields to the fields that Openfire actually supports.
		fieldLookup.put("jid", "Username");
		fieldLookup.put("username", "Username");
		fieldLookup.put("first", "Name");
		fieldLookup.put("last", "Name");
		fieldLookup.put("nick", "Name");
		fieldLookup.put("name", "Name");
		fieldLookup.put("email", "Email");
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.xmpp.component.Component#getName()
	 */
	@Override
	public String getName() {
		return pluginManager.getName(this);
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.xmpp.component.Component#getDescription()
	 */
	@Override
	public String getDescription() {
		return pluginManager.getDescription(this);
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.jivesoftware.openfire.container.Plugin#initializePlugin(org.jivesoftware.openfire.container.PluginManager, java.io.File)
	 */
	@Override
	public void initializePlugin(PluginManager manager, File pluginDirectory) {
		pluginManager = manager;

		componentManager = ComponentManagerFactory.getComponentManager();
		try {
			componentManager.addComponent(serviceName, this);
		} catch (ComponentException e) {
			Log.error(e.getMessage(), e);
		}
		PropertyEventDispatcher.addListener(this);
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.xmpp.component.Component#initialize(org.xmpp.packet.JID, org.xmpp.component.ComponentManager)
	 */
	@Override
	public void initialize(JID jid, ComponentManager componentManager) {
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.xmpp.component.Component#start()
	 */
	@Override
	public void start() {
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.jivesoftware.openfire.container.Plugin#destroyPlugin()
	 */
	@Override
	public void destroyPlugin() {
		PropertyEventDispatcher.removeListener(this);
		pluginManager = null;
		try {
			componentManager.removeComponent(serviceName);
			componentManager = null;
		} catch (Exception e) {
			if (componentManager != null) {
				Log.error(e.getMessage(), e);
			}
		}
		serviceName = null;
		userManager = null;
		excludedFields = null;
		serverName = null;
		fieldLookup = null;
		reverseFieldLookup = null;
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.xmpp.component.Component#shutdown()
	 */
	@Override
	public void shutdown() {
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.xmpp.component.Component#processPacket(org.xmpp.packet.Packet)
	 */
	@Override
	public void processPacket(Packet p) {
		if (!(p instanceof IQ)) {
			return;
		}
		final IQ packet = (IQ) p;

		if (packet.getType().equals(IQ.Type.error) || packet.getType().equals(IQ.Type.result)) {
			return;
		}

		// Packet p is an IQ stanza of type GET or SET. Therefor, it _must_ be
		// replied to.
		final IQ replyPacket = handleIQRequest(packet);

		try {
			componentManager.sendPacket(this, replyPacket);
		} catch (ComponentException e) {
			Log.error(e.getMessage(), e);
		}

	}

	/**
	 * Handles IQ requests. This method throws an IllegalArgumentException if an IQ stanza is supplied that is not a request (if the stanza
	 * is not of type 'get' or 'set'). This method will either throw an Exception, or return a non-null IQ stanza of type 'error' or
	 * 'result', as XMPP Core specifies that <strong>all</strong> IQ request stanza's (type 'get' or 'set') MUST be replied to.
	 * 
	 * @param iq
	 *            The IQ stanza that forms the request.
	 * @return The response to the request.
	 */
	private IQ handleIQRequest(IQ iq) {
		final IQ replyPacket; // 'final' to ensure that it is set.

		if (iq == null) {
			throw new IllegalArgumentException("Argument 'iq' cannot be null.");
		}

		final IQ.Type type = iq.getType();
		if (type != IQ.Type.get && type != IQ.Type.set) {
			throw new IllegalArgumentException("Argument 'iq' must be of type 'get' or 'set'");
		}

		final Element childElement = iq.getChildElement();
		if (childElement == null) {
			replyPacket = IQ.createResultIQ(iq);
			replyPacket.setError(new PacketError(Condition.bad_request, org.xmpp.packet.PacketError.Type.modify,
					"IQ stanzas of type 'get' and 'set' MUST contain one and only one child element (RFC 3920 section 9.2.3)."));
			return replyPacket;
		}

		final String namespace = childElement.getNamespaceURI();
		if (namespace == null) {
			replyPacket = IQ.createResultIQ(iq);
			replyPacket.setError(Condition.feature_not_implemented);
			return replyPacket;
		}

		if (namespace.equals(NAMESPACE_JABBER_IQ_SEARCH)) {
			replyPacket = handleSearchRequest(iq);
		} else if (namespace.equals(IQDiscoInfoHandler.NAMESPACE_DISCO_INFO)) {
			replyPacket = handleDiscoInfo(iq);
		} else if (namespace.equals(IQDiscoItemsHandler.NAMESPACE_DISCO_ITEMS)) {
			replyPacket = IQ.createResultIQ(iq);
			replyPacket.setChildElement("query", IQDiscoItemsHandler.NAMESPACE_DISCO_ITEMS);
		} else {
			// don't known what to do with this.
			replyPacket = IQ.createResultIQ(iq);
			replyPacket.setError(Condition.feature_not_implemented);
		}

		return replyPacket;
	}

	/**
	 * Creates a response specific to the search plugin to Disco#Info requests.
	 * 
	 * @param iq
	 *            The IQ stanza that contains the request.
	 * @return An IQ stanza, formulated as an answer to the received request.
	 */
	private static IQ handleDiscoInfo(IQ iq) {
		if (iq == null) {
			throw new IllegalArgumentException("Argument 'iq' cannot be null.");
		}

		if (!iq.getChildElement().getNamespaceURI().equals(IQDiscoInfoHandler.NAMESPACE_DISCO_INFO) || iq.getType() != Type.get) {
			throw new IllegalArgumentException("This is not a valid disco#info request.");
		}

		final IQ replyPacket = IQ.createResultIQ(iq);

		final Element responseElement = replyPacket.setChildElement("query", IQDiscoInfoHandler.NAMESPACE_DISCO_INFO);
		responseElement.addElement("identity").addAttribute("category", "directory").addAttribute("type", "user")
				.addAttribute("name", "User Search");
		responseElement.addElement("feature").addAttribute("var", NAMESPACE_JABBER_IQ_SEARCH);
		responseElement.addElement("feature").addAttribute("var", IQDiscoInfoHandler.NAMESPACE_DISCO_INFO);
		responseElement.addElement("feature").addAttribute("var", ResultSet.NAMESPACE_RESULT_SET_MANAGEMENT);

		return replyPacket;
	}

	private IQ handleSearchRequest(IQ packet) {
		if (!serviceEnabled) {
			return replyDisabled(packet);
		}

		switch (packet.getType()) {
		case get:
			return processGetPacket(packet);

		case set:
			return processSetPacket(packet);

		default:
			// we can safely ignore 'error' and 'result' typed iq stanzas.
			return null;
		}
	}

	/**
	 * Constructs a IQ result stanza, based on the request stanza that is provided as an argument. The stanza tells the recipient that this
	 * service is currently unavailable.
	 * 
	 * @param packet
	 *            The request IQ stanza to which a result will be returned.
	 * @return A result stanza, telling the user that this service is unavailable.
	 */
	private static IQ replyDisabled(IQ packet) {
		IQ replyPacket = IQ.createResultIQ(packet);
		Element reply = replyPacket.setChildElement("query", NAMESPACE_JABBER_IQ_SEARCH);
		final DataForm unavailableForm = new DataForm(DataForm.Type.cancel);
		unavailableForm.setTitle(LocaleUtils.getLocalizedString("advance.user.search.title", "search"));
		unavailableForm.addInstruction(LocaleUtils.getLocalizedString("search.service_unavailable", "search"));
		reply.add(unavailableForm.getElement());

		return replyPacket;
	}

	/**
	 * Processes an IQ stanza of type 'get', which in the context of 'Jabber Search' is a request for available search fields.
	 * 
	 * @param packet
	 *            An IQ stanza of type 'get'
	 * @return A result IQ stanza that contains the possbile search fields.
	 */
	private IQ processGetPacket(IQ packet) {
		if (!packet.getType().equals(IQ.Type.get)) {
			throw new IllegalArgumentException("This method only accepts 'get' typed IQ stanzas as an argument.");
		}
		IQ replyPacket = IQ.createResultIQ(packet);

		Element queryResult = DocumentHelper.createElement(QName.get("query", NAMESPACE_JABBER_IQ_SEARCH));

		String instructions = LocaleUtils.getLocalizedString("advance.user.search.details", "search");

		// non-data form
		queryResult.addElement("instructions").addText(instructions);
		queryResult.addElement("first");
		queryResult.addElement("last");
		queryResult.addElement("nick");
		queryResult.addElement("email");

		DataForm searchForm = new DataForm(DataForm.Type.form);
		searchForm.setTitle(LocaleUtils.getLocalizedString("advance.user.search.title", "search"));
		searchForm.addInstruction(instructions);

		searchForm.addField("FORM_TYPE", null, FormField.Type.hidden).addValue(NAMESPACE_JABBER_IQ_SEARCH);

		searchForm.addField("search", LocaleUtils.getLocalizedString("advance.user.search.search", "search"), FormField.Type.text_single)
				.setRequired(true);

		for (String searchField : getFilteredSearchFields()) {
			final FormField field = searchForm.addField();
			field.setVariable(searchField);
			field.setType(FormField.Type.boolean_type);
			field.addValue("1");
			field.setLabel(LocaleUtils.getLocalizedString("advance.user.search." + searchField.toLowerCase(), "search"));
			field.setRequired(false);
		}

		queryResult.add(searchForm.getElement());
		replyPacket.setChildElement(queryResult);

		return replyPacket;
	}

	/**
	 * Processes an IQ stanza of type 'set', which in the context of 'Jabber Search' is a search request.
	 * 
	 * @param packet
	 *            An IQ stanza of type 'get'
	 * @return A result IQ stanza that contains the possbile search fields.
	 */
	private IQ processSetPacket(IQ packet) {
		if (!packet.getType().equals(IQ.Type.set)) {
			throw new IllegalArgumentException("This method only accepts 'set' typed IQ stanzas as an argument.");
		}

		JID fromJID = packet.getFrom();

		final IQ resultIQ;

		// check if the request complies to the XEP-0055 standards
		if (!isValidSearchRequest(packet)) {
			resultIQ = IQ.createResultIQ(packet);
			resultIQ.setError(Condition.bad_request);
			return resultIQ;
		}

		final Element incomingForm = packet.getChildElement();
		final boolean isDataFormQuery = (incomingForm.element(QName.get("x", "jabber:x:data")) != null);

		final Element rsmElement = incomingForm.element(QName.get("set", ResultSet.NAMESPACE_RESULT_SET_MANAGEMENT));

		if (rsmElement != null) {
			final Element maxElement = rsmElement.element("max");
			final Element startIndexElement = rsmElement.element("index");

			int startIndex = 0;
			if (startIndexElement != null) {
				startIndex = Integer.parseInt(startIndexElement.getTextTrim());
			}

			int max = -1;
			if (maxElement != null) {
				max = Integer.parseInt(maxElement.getTextTrim());
			}

			final Set<User> searchResults = performSearch(incomingForm, startIndex, max);

			if (groupOnly) {
				Collection<Group> groups = GroupManager.getInstance().getGroups(fromJID);
				Set<User> allSearchResults = new HashSet<User>(searchResults);
				searchResults.clear();
				for (User user : allSearchResults) {
					for (Group group : groups) {
						if (group.isUser(user.getUID())) {
							searchResults.add(user);
						}
					}
				}
			}

			// apply RSM
			final List<User> rsmResults;
			final ResultSet<User> rs = new ResultSetImpl<User>(searchResults);
			try {
				rsmResults = rs.applyRSMDirectives(rsmElement);
			} catch (NullPointerException e) {
				final IQ itemNotFound = IQ.createResultIQ(packet);
				itemNotFound.setError(Condition.item_not_found);
				return itemNotFound;
			}
			if (isDataFormQuery) {
				resultIQ = replyDataFormResult(rsmResults, packet);
			} else {
				resultIQ = replyNonDataFormResult(rsmResults, packet);
			}

			// add the additional 'set' element.
			final Element set = rs.generateSetElementFromResults(rsmResults);
			resultIQ.getChildElement().add(set);

		} else {
			final Set<User> searchResults = performSearch(incomingForm);

			if (groupOnly) {
				Collection<Group> groups = GroupManager.getInstance().getGroups(fromJID);
				Set<User> allSearchResults = new HashSet<User>(searchResults);
				searchResults.clear();
				for (User user : allSearchResults) {
					for (Group group : groups) {
						if (group.isUser(user.getUID())) {
							searchResults.add(user);
						}
					}
				}
			}

			// don't apply RSM
			if (isDataFormQuery) {
				resultIQ = replyDataFormResult(searchResults, packet);
			} else {
				resultIQ = replyNonDataFormResult(searchResults, packet);
			}
		}

		return resultIQ;
	}

	public Set<User> filterGroupSearchResults(JID jid, Set<User> searchResults) {
		if (groupOnly) {
			Collection<Group> groups = GroupManager.getInstance().getGroups(jid);
			Set<User> allSearchResults = new HashSet<User>(searchResults);
			searchResults.clear();
			for (User user : allSearchResults) {
				for (Group group : groups) {
					if (group.isUser(user.getUID())) {
						searchResults.add(user);
					}
				}
			}
		}
		return searchResults;
	}

	/**
	 * This method checks if the search request that was received is a valid JABBER:IQ:SEARCH request. In other words, it checks if the
	 * search request is spec compliant (XEP-0055). It does this by checking:
	 * <ul>
	 * <li>if the IQ stanza is of type 'set';</li>
	 * <li>if a child element identified by the jabber:iq:search namespace is supplied;</li>
	 * <li>if the stanza child element is has valid children itself.</li>
	 * </ul>
	 * 
	 * @param iq
	 *            The IQ object that should include a jabber:iq:search request.
	 * @return ''true'' if the supplied IQ stanza is a spec compliant search request, ''false'' otherwise.
	 */
	@SuppressWarnings("unchecked")
	public static boolean isValidSearchRequest(IQ iq) {

		if (iq == null) {
			throw new IllegalArgumentException("Argument 'iq' cannot be null.");
		}

		if (iq.getType() != IQ.Type.set) {
			return false;
		}

		final Element childElement = iq.getChildElement();
		if (childElement == null) {
			return false;
		}

		if (!childElement.getNamespaceURI().equals(NAMESPACE_JABBER_IQ_SEARCH)) {
			return false;
		}

		if (!childElement.getName().equals("query")) {
			return false;
		}

		final List<Element> fields = childElement.elements();
		if (fields.size() == 0) {
			return false;
		}

		for (Element element : fields) {
			final String name = element.getName();
			if (!validSearchRequestFields.contains(name)) {
				return false;
			}

			// TODO: check dataform validity.
			// if (name.equals("x") && !isValidDataForm(element))
			// {
			// return false;
			// }

			if (name.equals("set") && !ResultSet.isValidRSMRequest(element)) {
				return false;
			}
		}

		return true;
	}

	private Set<User> performSearch(Element incomingForm, int startIndex, int max) {
		Set<User> users = new HashSet<User>();

		Hashtable<String, String> searchList = extractSearchQuery(incomingForm);

		for (Entry<String, String> entry : searchList.entrySet()) {
			String field = entry.getKey();
			String query = entry.getValue();

			Collection<User> foundUsers = new ArrayList<User>();

			if (userManager != null && query.length() > 0 && !query.equals(NAMESPACE_JABBER_IQ_SEARCH)) {
				if (max >= 0) {
					foundUsers.addAll(userManager.findUsers(new HashSet<String>(Arrays.asList(field)), query, startIndex, max));
				} else {
					foundUsers.addAll(userManager.findUsers(new HashSet<String>(Arrays.asList(field)), query));
				}
			}

			// occasionally a null User is returned so filter them out
			for (User user : foundUsers) {
				if (user != null) {
					users.add(user);
				}
			}
		}
		return users;
	}

	/**
	 * Performs a search based on form data, and returns the search results.
	 * 
	 * @param incomingForm
	 *            The form containing the search data
	 * @return A set of users that matches the search criteria.
	 */
	private Set<User> performSearch(Element incomingForm) {
		return performSearch(incomingForm, -1, -1);
	}

	/**
	 * This utilty method extracts the search query from the request. A query is defined as a set of key->value pairs, where the key denotes
	 * a search field, and the value contains the value that was filled out by the user for that field.
	 * 
	 * The query can be specified in one of two ways. The first way is a query is formed is by filling out any of the the standard search
	 * fields. The other search method makes use of extended data forms. Search queries that are supplied to this
	 * {@link #extractSearchQuery(Element)} that make use of this last method get forwarded to {@link #extractExtendedSearchQuery(Element)}.
	 * 
	 * @param incomingForm
	 *            The form from which to extract the query
	 * @return The search query for a particular user search request.
	 */
	@SuppressWarnings("unchecked")
	private Hashtable<String, String> extractSearchQuery(Element incomingForm) {
		if (incomingForm.element(QName.get("x", "jabber:x:data")) != null) {
			// forward the request.
			return extractExtendedSearchQuery(incomingForm);
		}

		final Hashtable<String, String> searchList = new Hashtable<String, String>();

		// since not all clients request which fields are available for
		// searching attempt to match submitted fields with available search
		// fields
		Iterator<Element> iter = incomingForm.elementIterator();
		while (iter.hasNext()) {
			Element element = iter.next();
			String name = element.getName();

			if (fieldLookup.containsKey(name)) {
				// make best effort to map the fields submitted by
				// the client to those that Openfire can search
				reverseFieldLookup.put(fieldLookup.get(name), name);
				searchList.put(fieldLookup.get(name), element.getText());
			}
		}

		return searchList;
	}

	/**
	 * Extracts a search query from a data form that makes use of data forms to specify the search request. This 'extended' way of
	 * constructing a search request is documented in XEP-0055, chapter 3.
	 * 
	 * @param incomingForm
	 *            The form from which to extract the query
	 * @return The search query for a particular user search request.
	 * @see #extractSearchQuery(Element)
	 */
	@SuppressWarnings("unchecked")
	private Hashtable<String, String> extractExtendedSearchQuery(Element incomingForm) {
		final Element dataform = incomingForm.element(QName.get("x", "jabber:x:data"));

		Hashtable<String, String> searchList = new Hashtable<String, String>();
		List<String> searchFields = new ArrayList<String>();
		String search = "";

		Iterator<Element> fields = dataform.elementIterator("field");
		while (fields.hasNext()) {
			Element searchField = fields.next();

			String field = searchField.attributeValue("var");
			String value = "";
			if (searchField.element("value") != null) {
				value = searchField.element("value").getTextTrim();
			}
			if (field.equals("search")) {
				search = value;
			} else if (value.equals("1")) {
				searchFields.add(field);
			}
		}

		for (String field : searchFields) {
			searchList.put(field, search);
		}

		return searchList;
	}

	/**
	 * Constructs a query that is returned as an IQ packet that contains the search results.
	 * 
	 * @param users
	 *            set of users that will be used to construct the search results
	 * @param packet
	 *            the IQ packet sent by the client
	 * @return the iq packet that contains the search results
	 */
	private IQ replyDataFormResult(Collection<User> users, IQ packet) {
		final DataForm searchResults = new DataForm(DataForm.Type.result);

		searchResults.addField("FORM_TYPE", null, FormField.Type.hidden);

		searchResults.addReportedField("jid", "JID", FormField.Type.jid_single);

		for (final String fieldName : getFilteredSearchFields()) {
			searchResults.addReportedField(fieldName,
					LocaleUtils.getLocalizedString("advance.user.search." + fieldName.toLowerCase(), "search"), FormField.Type.text_single);
		}

		for (final User user : users) {
			final String username = JID.unescapeNode(user.getUsername());

			final Map<String, Object> item = new HashMap<String, Object>();
			item.put("jid", username + "@" + serverName);

			item.put(LocaleUtils.getLocalizedString("advance.user.search.username", "search"), username);

			item.put(LocaleUtils.getLocalizedString("advance.user.search.name", "search"),
					(user.isNameVisible() ? removeNull(user.getName()) : ""));

			item.put(LocaleUtils.getLocalizedString("advance.user.search.email", "search"),
					(user.isEmailVisible() ? removeNull(user.getEmail()) : ""));

			searchResults.addItemFields(item);
		}

		IQ replyPacket = IQ.createResultIQ(packet);
		Element reply = replyPacket.setChildElement("query", NAMESPACE_JABBER_IQ_SEARCH);
		reply.add(searchResults.getElement());

		return replyPacket;
	}

	/**
	 * Constructs a query that is returned as an IQ packet that contains the search results.
	 * 
	 * @param users
	 *            set of users that will be used to construct the search results
	 * @param packet
	 *            the IQ packet sent by the client
	 * @return the iq packet that contains the search results
	 */
	private IQ replyNonDataFormResult(Collection<User> users, IQ packet) {
		IQ replyPacket = IQ.createResultIQ(packet);
		Element replyQuery = replyPacket.setChildElement("query", NAMESPACE_JABBER_IQ_SEARCH);

		for (User user : users) {
			Element item = replyQuery.addElement("item");
			String username = JID.unescapeNode(user.getUsername());
			item.addAttribute("jid", username + "@" + serverName);

			// return to the client the same fields that were submitted
			for (String field : reverseFieldLookup.keySet()) {
				if ("Username".equals(field)) {
					Element element = item.addElement(reverseFieldLookup.get(field));
					element.addText(username);
				}

				if ("Name".equals(field)) {
					Element element = item.addElement(reverseFieldLookup.get(field));
					element.addText(user.isNameVisible() ? removeNull(user.getName()) : "");
				}

				if ("Email".equals(field)) {
					Element element = item.addElement(reverseFieldLookup.get(field));
					element.addText(user.isEmailVisible() ? removeNull(user.getEmail()) : "");
				}
			}
		}

		return replyPacket;
	}

	/**
	 * Returns the service name of this component, which is "search" by default.
	 * 
	 * @return the service name of this component.
	 */
	public String getServiceName() {
		return serviceName;
	}

	/**
	 * Sets the service name of this component, which is "search" by default. If the name is different than the existing name the plugin
	 * will remove itself from the ComponentManager and then add itself back using the new name.
	 * 
	 * @param name
	 *            the service name of this component.
	 */
	public void setServiceName(String name) {
		changeServiceName(name);
		JiveGlobals.setProperty(SERVICENAME, name);
	}

	/**
	 * Checks if the search service is enabled.
	 * 
	 * @return true if search service is enabled.
	 */
	public boolean getServiceEnabled() {
		return serviceEnabled;
	}

	/**
	 * Enables or disables the search service. When disabled, when a client tries to do a search they will receive an XForm informing that
	 * the service is unavailable.
	 * 
	 * @param enabled
	 *            true if group permission checking should be disabled.
	 */
	public void setServiceEnabled(boolean enabled) {
		serviceEnabled = enabled;
		JiveGlobals.setProperty(SERVICEENABLED, enabled ? "true" : "false");
	}

	/**
	 * Returns the collection of searchable field names that does not include the fields listed in the EXCLUDEDFIELDS property list.
	 * 
	 * @return collection of searchable field names.
	 */
	public Collection<String> getFilteredSearchFields() {
		Collection<String> searchFields;

		// See if the installed provider supports searching. If not, workaround
		// by providing our own searching.
		try {
			searchFields = new ArrayList<String>(userManager.getSearchFields());
		} catch (UnsupportedOperationException uoe) {
			// Use a SearchPluginUserManager instead.
			searchFields = getSearchPluginUserManagerSearchFields();
		}

		searchFields.removeAll(excludedFields);

		return searchFields;
	}

	/**
	 * Restricts which fields can be searched on and shown to clients. This can be used in the case of preventing users email addresses from
	 * being revealed as part of the search results.
	 * 
	 * @param excludedFields
	 *            fields that can not be searched on or shown to the client
	 */
	public void setExcludedFields(Collection<String> excludedFields) {
		this.excludedFields = excludedFields;
		JiveGlobals.setProperty(EXCLUDEDFIELDS, StringUtils.collectionToString(excludedFields));
	}

	/**
	 * Checks if the search service is restricted to groups.
	 * 
	 * @return true if restricted to groups.
	 */
	public boolean isGroupOnly() {
		return groupOnly;
	}

	/**
	 * Sets the search service scope.
	 * 
	 * @param groupOnly
	 *            true if group only.
	 */
	public void setGroupOnly(boolean groupOnly) {
		this.groupOnly = groupOnly;
		JiveGlobals.setProperty(GROUPONLY, groupOnly ? "true" : "false");
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.jivesoftware.util.PropertyEventListener#propertySet(java.lang.String, java.util.Map)
	 */
	@Override
	public void propertySet(String property, Map<String, Object> params) {
		if (property.equals(SERVICEENABLED)) {
			this.serviceEnabled = Boolean.parseBoolean((String) params.get("value"));
		} else if (property.equals(SERVICENAME)) {
			changeServiceName((String) params.get("value"));
		} else if (property.equals(EXCLUDEDFIELDS)) {
			excludedFields = StringUtils.stringToCollection(JiveGlobals.getProperty(EXCLUDEDFIELDS, (String) params.get("value")));
		} else if (property.equals(GROUPONLY)) {
			this.groupOnly = Boolean.parseBoolean((String) params.get("value"));
		}
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.jivesoftware.util.PropertyEventListener#propertyDeleted(java.lang.String, java.util.Map)
	 */
	@Override
	public void propertyDeleted(String property, Map<String, Object> params) {
		if (property.equals(SERVICEENABLED)) {
			this.serviceEnabled = true;
		} else if (property.equals(SERVICENAME)) {
			changeServiceName("search");
		} else if (property.equals(EXCLUDEDFIELDS)) {
			excludedFields = new ArrayList<String>();
		} else if (property.equals(GROUPONLY)) {
			this.groupOnly = false;
		}
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.jivesoftware.util.PropertyEventListener#xmlPropertySet(java.lang.String, java.util.Map)
	 */
	@Override
	public void xmlPropertySet(String property, Map<String, Object> params) {
		// not used
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.jivesoftware.util.PropertyEventListener#xmlPropertyDeleted(java.lang.String, java.util.Map)
	 */
	@Override
	public void xmlPropertyDeleted(String property, Map<String, Object> params) {
		// not used
	}

	private void changeServiceName(String serviceName) {
		if (serviceName == null) {
			throw new NullPointerException("Service name cannot be null");
		}

		if (this.serviceName.equals(serviceName)) {
			return;
		}

		// Re-register the service.
		try {
			componentManager.removeComponent(this.serviceName);
		} catch (Exception e) {
			Log.error(e.getMessage(), e);
		}

		try {
			componentManager.addComponent(serviceName, this);
		} catch (Exception e) {
			Log.error(e.getMessage(), e);
		}

		this.serviceName = serviceName;
	}

	/**
	 * Comparator that compares String objects, ignoring capitalization.
	 */
	private class CaseInsensitiveComparator implements Comparator<String> {
		@Override
		public int compare(String s1, String s2) {
			return s1.compareToIgnoreCase(s2);
		}
	}

	/**
	 * Returns the trimmed argument, or an empty String object of null was supplied as an argument.
	 * 
	 * @param s
	 *            The String to be trimmed.
	 * @return String object that does not start or end with whitespace characters.
	 */
	private String removeNull(String s) {
		if (s == null) {
			return "";
		}

		return s.trim();
	}

	/**
	 * Returns the collection of field names that can be used to search for a user. Typical fields are username, name, and email. These
	 * values can be used to contruct a data form.
	 * 
	 * @return the collection of field names that can be used to search.
	 */
	public Collection<String> getSearchPluginUserManagerSearchFields() {
		return Arrays.asList("Username", "Name", "Email");
	}
}
